查看原文
其他

小游戏技术方案实现探索

王栋辉 哔哩哔哩技术
2024-08-14

本期作者



王栋辉

哔哩哔哩开发工程师


一个名为”大力出奇迹“的增长活动,于2023年4月26日在哔哩哔哩app上悄然发布。活动的其中一个核心玩法是:用户可以通过玩一个小游戏,来获取活动代币(其名为”大力币“)。在这个游戏中,用户在8秒内点击一个按钮,每多点击10次,就能多获取一定数量的代币。作为活动的前端部分的主要开发人员,我将把这个游戏拆分成3个主要部分:”倒计时“、”游戏主体“、”撒金币“和”额外赠送机会“等其他动效,并对其技术方案和实现细节进行详细地介绍。


【视频 —— 小游戏的整个操作流程】


一、倒计时


在用户点开游戏弹窗以后,屏幕上会显示时长为4秒的”倒计时“动画。用户可以通过这段缓冲时间,观察整个游戏界面,并做好疯狂点击的准备!



【图片 —— 倒计时】


 1.1 技术方案


”倒计时“动画总共涉及到4张图片。在1秒钟内,我们需要调整图片的缩放和透明度。而在上一秒与下一秒之间,我们需要按序切换上一张图片和下一张图片的显示。经过这样的分析,我们的核心诉求就明确为:按照一条时间轴,调整4张图片的css样式。

这里我选择使用gsap。gsap是一个简单好用的动画库,它最大的优点在于,它提供了时间轴对象(timeline),可以按照一个时间轴精确地操控多个对象的动画。其次是,它内置支持了更改对象的css属性。就凭这2个优点,gsap完美满足了我们的核心诉求!

gsap的官网地址是GreenSock。如果你有兴趣,可以去研究一下。


1.2 实现细节


凭借gsap强大的时间轴对象,我们就可以”简单粗暴“地实现”倒计时“动画了。

首先我们先为4张图片创建4个img标签,再在外层创建一个父元素作为界面的遮罩。


<div class="countdown-shadow">  <img id="countdown3" :src="countdown3" />  <img id="countdown2" :src="countdown2" />  <img id="countdown1" :src="countdown1" />  <img id="countdown4" :src="countdown4" /></div>


然后,我们可以使用gsap的时间轴对象(timeline),依次操控图片的display、scale和opacity属性。

我们先让第一张图片显示出来,可以调用timeline的set方法来设置图片的起始属性。


const tl = gsap.timeline()this.tl = tltl.set('.countdown-shadow', { display: 'flex' })  .set('#countdown3', { display: 'block', opacity: 1 })


我们再让这张图片在1秒内,scale属性由1变为0.7,opacity属性由1变为0,最后消失。


to('#countdown3', {      duration: 1,      scale: 0.7,      opacity: 0,      display: 'none'    })


紧接着,我们让下一张图片在前0.5秒内,scale属性由0.7变为1,opacity属性由0变为1。再在后0.5秒内,scale属性由1变为0.7,opacity属性由1变为0。最后消失。


.set('#countdown2', {      scale: 0.7,      opacity: 0,      display: 'block'    })    .to('#countdown2', {      duration: 0.5,      scale: 1,      opacity: 1    })    .to('#countdown2', {      duration: 0.5,      scale: 0.7,      opacity: 0,      display: 'none'    })


后面的图片的显示逻辑以此类推,这里就不多做赘述了。我们看到,gsap的timeline对象提供了set和to这两个非常好用地、可以精确操控对象动画和属性的方法,并可以链式调用。这种实现方式是偏过程式的编程方式,非常符合我们在现实中对”倒计时“这种动画的认知。


二、游戏主体


在倒计时结束后,游戏正式开始,整个游戏主体暴露在用户面前。用户可以点击界面下方的按钮,使界面上方的角色(其名为”小电视“)做出按压的动作。用户每点击1次,界面中间的进度条就会前进一格。用户每点击10次,进度条就会清空,同时用户会获得一定数量的大力币,当前获得的大力币数量会显示在界面偏下方的屏幕上。用户点击的频率越快,小电视按压的频率也就越快。

当然这个游戏也有2个限制条件:时间限制为8秒,用户点击次数的上限为150次。当时间限制达成时,游戏进入结束状态,对用户获取的大力币进行结算,并为用户提供了2种选择:再玩一次(如果还有游戏次数)和退出游戏。


【图片 —— 游戏主体】


2.1 技术方案


在最早的需求阶段中,界面中只有小电视和点击按钮两个部分,所以我们的第一版技术方案是使用第三方动画库frame-animationhttps://www.npmjs.com/package/frame-animation),为小电视做一个帧动画。关于”用户点击的频率越快,小电视按压的频率也就越快“这个需求,我们为小电视设置了4个速度档位。当用户的点击频率达到某个档位时,我们将上一个动画对象销毁掉,然后重新创建一个播放速率不同的动画对象。

这种技术方案的弊端有2个:第一是动画在切换速度档位的时候,会有明显的卡顿感。第二是扩展性较差,如果需求扩展,界面上的动画变得更复杂,这个技术方案就不能很好地满足需求了。


【视频 —— 用frame-animation实现的游戏动画(示意)】


在第二版技术方案中,我提出用gsap + 雪碧图的方式,自己实现小电视的帧动画。实现原理很简单,就是用gsap逐渐改变雪碧图的background-position属性。这种方案的优势有2个:第一是gsap对动画进行了性能优化,而且支持在两次变化的衔接处进行缓动效果(easing),所以解决了第一版方案中,切换速度档位时有卡顿感的弊端。第二是因为是自己实现动画,所以扩展性较好,可以对动画实现自定义的改动和优化。

然而这种技术方案也有弊端:第一是gsap的本质是更改对象的css属性,只能满足简单的帧动画和位移动画,一旦动画对象变多,我们需要编写成倍的代码来实现动画本身(我们还没考虑动画对象之间还要有交互逻辑呢!)。


【视频 —— 用gsap + 雪碧图实现的游戏动画(示意)】


在第三版、也是最终版的技术方案中,我选择了pixijs。(距官网所说,)pixijs是一个基于webGL的2D渲染引擎,其实就是基于webGL封装了一系列简单易懂的API,让我们能快速搭建一个复杂的2D动画方案。

pixijs的官网地址是PixiJS: https://pixijs.io/guides/basics/what-pixijs-is.html。如果你有兴趣,可以去研究一下。

使用pixijs的最大的2个优势是:第一,pixijs的API大大简化了实现动画的代码。第二,pixijs充分利用了GPU,(天哪,我们终于想到要利用GPU了!)使复杂动画的性能有了巨大的提升。

这次我们使用的是pixijs的v7版本,也是其最新版本。我一开始的想法是,要做第一个吃螃蟹的人!要勇于探索最新的技术!的确,v7版本优化了一些API,比如(之前臭不可闻的)Loader类,也帮助我们实现了一些v6版本实现不了的小功能,比如v7将Sprite对象的currentFrame属性从只读的变成可写的,让我们实现了完全可控的进度条动画。但是,v7版本相较于v6也有了破坏性的改动,比如去掉了polyfill和对老旧的浏览器的支持,这一点在之后对我们造成了很大的困扰。


【视频 —— 用pixijs实现的游戏动画】


同时,我们还需要一个事件库,实现一个沟通vue实例和pixijs对象的事件总线(eventBus)。我选择了mitt,原因无他,惟轻量尔。

mitt的npm地址是https://www.npmjs.com/package/mitt。如果你有兴趣,可以去研究一下。


以上3种技术方案的优点和缺点,我总结成了如下的一张表格。


技术方案
优点
缺点

frame-animation

实现最简单

切换速度档位时,有卡顿

gsap + 雪碧图

实现较简单,扩展性较好

切换速度档位时,没有卡顿

可实现的动画类型和动画对象较少,不支持复杂的动画方案

pixijs

性能最好

扩展性最好

支持复杂的动画类型和多个动画对象同屏

实现最复杂

v7有兼容性问题

【表格 —— 游戏主体技术方案对比】


 2.2 实现细节


在介绍游戏主体的实现细节时,我将把主要笔墨花在介绍如何构建pixijs的主要类,然后分散地介绍我遇到的一些问题和解决方案。


2.2.1 主要类的构建


【图片 —— 主要类的结构】


2.2.1.1 Application


首先我们要构建的,是pixijs中最重要的一个类:Application。Application类实例是沟通canvas和pixijs其他类对象的重要桥梁,你只需要在入参的view属性里传入canvas实例,并自定义一些其他参数就行。


import { Application } from 'pixi.js'
const canvas = document.getElementById('game')const width = window.screen.widthconst height = (667 * width) / 375// pixi Applicationconst application = new Application({    width,    height,    resolution: window.devicePixelRatio,    view: canvas,    backgroundAlpha: 0})


值得一提的是,如果你想让背景变成透明的,可以传入backgroundAlpha: 0这个属性,至少这在v7是可行的。


2.2.1.2 资源加载


然后,我们需要加载一些资源,比如图片、字体等。

引入多张图片的最佳实践肯定是使用import语句,这里我们现在另一个文件里列出所有要用的图片,然后导出。


import body1 from '@/assets/pixi/body/body1.png'import body2 from '@/assets/pixi/body/body2.png'import body3 from '@/assets/pixi/body/body3.png'import body4 from '@/assets/pixi/body/body4.png'import body5 from '@/assets/pixi/body/body5.png'import body6 from '@/assets/pixi/body/body6.png'import body7 from '@/assets/pixi/body/body7.png'import body8 from '@/assets/pixi/body/body8.png'
export const resources = {  body1,  body2,  body3,  body4,  body5,  body6,  body7,  body8}


为了使用导出的图片资源,我们要使用pixijs的另一个类:Assets。在v7版本中,Assets类替换了v6版本的Loader类,用法更加简单:我们只需要将图片一个个add到Assets类中,然后调用load方法进行加载。


// 统一加载图片资源load(resources) {const resourceKeys = []
Object.keys(resources).forEach(key => {  const url = resources[key]  Assets.add(key, url, { crossOrigin: 'anonymous' })  resourceKeys.push(key)})
return Assets.load(resourceKeys)}


add有两个主要入参:key是你为图片资源的命名,后续你可以用这个key来引用这个图片;url是这个图片实际的路径,它最好是绝对路径。第三个入参是可选项,因为v6版本的Loader有图片跨域问题,加上crossOrigin: 'anonymous'可以解决该问题,我只是把这个选项沿用到了v7的Assets上,是否必要没有作验证。

为什么我说url最好是绝对路径?因为我在将项目发布到线上以后,图片路径与构建配置的publicPath合并以后出现了问题。如果合并以后的路径没有协议(http/https),Assets仍然会将其视作一个相对路径,并在前面拼接上页面的path。所以你必须在url前面手动拼上完整的路径前缀。


/** * 对Assets加载的资源url做特殊处理 * 对转成base64的url,把base64的部分截出来作为url * 对普通url,在前面加上https:,变成绝对路径 */const fixAssetsUrl = url => {  const base64Reg = /data:image\/png;base64,.*/g  const matchRes = url.match(base64Reg)  // 匹配base64的部分  if (matchRes) {    return matchRes[0]  }  // 普通的url  const prefix = process.env.NODE_ENV === 'production' ? 'https:' : ''  return prefix + url}


值得一提的是,Assets类会将base64格式的路径视为绝对路径。

如果你有用到特殊字体,为了保证字体在pixijs对象渲染之前被加载完成,你必须手动加载字体文件。这里我使用了第三方库fontfaceobserver,这也是pixijs官方推荐的加载字体的方案。(https://pixijs.io/guides/basics/text.html - Loading and Using Fonts


import FontFaceObserver from 'fontfaceobserver'  function loadFont(fontFamilyName, timeout = 10000) {  const fontOb = new FontFaceObserver(fontFamilyName, {})  return fontOb.load(null, timeout)}  Promise.all([  this.loadFont('Alibaba PuHuiTi Regular'),  this.loadFont('Alibaba PuHuiTi'),  this.loadFont('DINCond-Black'),  this.loadFont('REEJI-TaikoMagicGB')])


值得一提的是,加载字体的load方法,默认的超时时间是3秒,这个时间在实际生产环境往往是不够的。为了更好的用户体验,我们可以将超时时间设置得长一点,这里设置了10秒。


2.2.1.3 Container & Sprite


接下来,我们就可以生成动画对象了。

如果你需要渲染一个静态对象,pixijs的Sprite类就可以满足你的要求。之前我们使用了Assets类的load方法加载了图片资源,这个方法会返回一个Promise对象,resolve出的是一个加载完的纹理对象(assets)。我们通过图片的key来引用assets中对应的纹理,并传入的Sprite类中,来创建一个静态”精灵“。


Assets.load(resourceKeys).then(assets => { ... })...const sprite = new Sprite(assets.body1)sprite.x = 0sprite.y = 0sprite.scale.set(0.8)


如果你需要渲染一个动画对象,pixijs的AnimatedSprite对象可以满足你的要求。在传入纹理时有2种选项。

第一种选项:如果你使用的是由一张张小图片拼起来的雪碧图,你手上会有一张雪碧图和一份描述雪碧图上各个小图的位置信息的JSON文件。(我强烈推荐你使用TexturePacker这个雪碧图制作软件,来尝试制作属于自己的雪碧图,顺便你会了解我这里说的JSON文件里,大概是哪些内容。)接着,你可以使用pixijs的Spritesheet类,把雪碧图纹理(你仍然需要把雪碧图add到Assets里)和JSON文件(通过import引入)传入Spritesheet类中。最后通过调用parse方法来获得一个Spritesheet实例。


import { Spritesheet } from 'pixi.js'import bodyJson from '@/assets/pixi/body/body.json'
const sheet = new Spritesheet(assets.body, bodyJson)const spritesheet = await sheet.parse()


通过parse方法获取的spritesheet实例中,有一个animations属性,你可以使用它来创建一个动画”精灵“。


import { AnimatedSprite } from 'pixi.js'
const sprite = new AnimatedSprite(spritesheet.animations)


第二种选项:你可以把一个纹理数组直接传入AnimatedSprite类中,可以直接生成由这些纹理组成的一个动画”精灵“。


import { AnimatedSprite } from 'pixi.js'
const textureArr = []for (let i = 1; i <= 8; i++) {  const texture = assets[`body${i}`]  textureArr.push(texture)}const sprite = new AnimatedSprite(textureArr)


默认动画精灵只会播放一次动画,如果你要让它循环播放动画,就把它的loop属性设置为true。你也可以设置它的animationSpeed属性,控制动画的播放倍速(这个属性为小电视的改变速度奠定了基础)。AnimatedSprite类继承自Sprite类,其他属性你可以参考Sprite类实例进行设置。


sprite.x = 0sprite.y = 0sprite.scale.set(scaleRatio) // 素材是3倍图,必须等比缩小sprite.loop = truesprite.animationSpeed = 0.8


如果把Sprite类实例比作HTML的img标签,那么Container类实例就是包裹这些img标签的父元素div标签。Container类,顾名思义,可以作为容器承载pixijs常用的图形和精灵实例,使代码层次更加分明。在实践中,我们往往会自定义一个类,继承Container类,然后在里面执行图形和精灵的创建逻辑。


import { Container, Sprite } from 'pixi.js'
class Body extends Container {    constructor(app, assets) {        super()        this.app = app        this.init(assets)    }        init(assets) {        const sprite = new Sprite(assets.body)        sprite.x = 0        sprite.y = 0        this.addChild(sprite)    }}


值得注意的是,你需要手动调用Container类实例的addChild,将图形和精灵加入到容器中,否则它们不会渲染到屏幕上!


2.2.1.4 自定义Stage类


随着我们创建的自定义Container类越来越多,我们迫切地需要一个自定义类,来承载这些Container类的创建、销毁和执行实例方法的逻辑。因此,自定义Stage类应运而生。

除了自定义Container类的逻辑,我们也可以将eventBus的逻辑也塞到这个类里面。Stage类就像一个广阔的舞台,默默见证着无数图形和精灵的精彩演绎。


import emitter from './utils/mitt'import Body from './objects/Body'import Progress from './objects/Progress'import BtnClick from './objects/BtnClick'
class Stage extends Container {  constructor(app, assets) {    super()
   // 创建自定义Container类实例    const body = new Body(app, assets)    const progress = new Progress(app, assets)    const btnClick = new BtnClick(app, assets)    ...    this.addChild(body, progress, btnClick)    ...            // eventBus 接收事件    // 总动画开始    emitter.on('Stage/start', () => {      body.play()    })    // Body改变速度    emitter.on('Body/changeSpeed', ratio => {      body.changeSpeed(ratio)    })    ...  }}


因为Stage类的”孩子”同样需要在屏幕上渲染,所以Stage类也必须继承自Container类。


2.2.1.5 自定义GameApplication类


还有最后2块逻辑,我们还放任了它们自由,它们分别是Application的创建资源加载。我们可以把这些逻辑全塞进一个自定义类里,这个类就是GameApplication


import { Application, Assets, utils } from 'pixi.js'import FontFaceObserver from 'fontfaceobserver'import { resources } from './resources'import Stage from './Stage'import emitter from './utils/mitt'
/** * 对Assets加载的资源url做特殊处理 * 对转成base64的url,把base64的部分截出来作为url * 对普通url,在前面加上https:,变成绝对路径 */const fixAssetsUrl = url => {  const base64Reg = /data:image\/png;base64,.*/g  const matchRes = url.match(base64Reg)  // 匹配base64的部分  if (matchRes) {    return matchRes[0]  }  // 普通的url  const prefix = process.env.NODE_ENV === 'production' ? 'https:' : ''  return prefix + url}
class GameApplication {  constructor(options) {    /**     * https://pixijs.io/guides/basics/text.html     * 用fontfaceobsever手动加载字体文件     * */    Promise.all([      this.loadFont('Alibaba PuHuiTi Regular'),      this.loadFont('Alibaba PuHuiTi'),      this.loadFont('DINCond-Black'),      this.loadFont('REEJI-TaikoMagicGB')    ])      .catch(err => {        console.error(err)      })      .finally(() => {        this.app = new Application(options)        this.load(resources).then(assets => {          this.init(assets)        })      })  }
 loadFont(fontFamilyName, timeout = 10000) {    const fontOb = new FontFaceObserver(fontFamilyName, {})    return fontOb.load(null, timeout)  }
 // 统一加载图片资源  load(resources) {    const resourceKeys = []
   Object.keys(resources).forEach(key => {      const url = resources[key]      const fixedUrl = fixAssetsUrl(url)      Assets.add(key, fixedUrl, { crossOrigin: 'anonymous' })      resourceKeys.push(key)    })
   return Assets.load(resourceKeys)  }
 init(assets) {    const stage = new Stage(this.app, assets)    this.stage = stage    this.app.stage.addChild(stage)    emitter.emit('game/ready')  }
 destroy() {    Assets.reset()    this.stage.destroy()    this.stage = null    utils.clearTextureCache()    this.app.destroy(true)    this.app = null  }}
export default GameApplication


可以看到,GameApplication类的入参是用于创建Application类实例的选项(options),它同时包含了创建Application类、加载字体、加载图片资源和销毁资源的逻辑。

注意有一步非常关键,你必须把自定义Stage类实例,添加到Application类实例的stage属性中。你可以理解为Application类实例的stage(app.stage)是最大的、内置的一个Container。这样,你就把所有可渲染的对象全部挂载到了Application类实例中,可以进行渲染了!


2.2.1.6 资源销毁


我们还剩一个容易忽视的小尾巴没有介绍,那就是如何销毁资源。我们创建了如此多的类,加载了很多图片和字体资源,这些资源在游戏结束后必须被销毁!

在自定义Stage类里,你只需要注销eventBus对所有事件的监听,防止其重复监听并触发事件处理函数。


 destroy() {    emitter.off('Stage/start')    emitter.off('Stage/reset')    emitter.off('Body/changeSpeed')    emitter.off('Progress/playOne')    emitter.off('Battery/update')    emitter.off('BtnClick/updateCount')    emitter.off('Stage/max')    emitter.off('BlastCount/update')    emitter.off('Stage/end')    super.destroy(true)  }


而在自定义GameApplication类中,我总结出了一套销毁资源的最佳实践。


 destroy() {    Assets.reset()    this.stage.destroy()    this.stage = null    utils.clearTextureCache()    this.app.destroy(true)    this.app = null  }


Assets.reset():用于清空Assets.load()加载的所有资源的key。(如果你的控制台里有很多warning: [ Resolver ] already has key: xxx overwriting 。这一条是必须的。)

this.stage.destroy():调用自定义Stage类实例的销毁函数。

this.stage = null:消除对Stage类实例的持有。(销毁持有后,过一会儿,pixijs的gc会自动执行Stage类和它持有的所有pixi的children的销毁函数。js的gc会销毁Stage类中所有持有的js对象)

this.app.destroy(true):调用Application类实例的销毁函数。传入true,以调用所有纹理和”孩子“的销毁函数。

this.app = null:消除对Application类实例的持有。


2.2.1.7 该节总结


2.2.1 这一小节中,我花了大量笔墨,详细介绍了游戏中主要类的构建思路和实现细节。我从头到尾介绍了pixijs原生的Application、Assets、Container和Sprite的使用方法,然后再从尾到头介绍了这些逻辑可以封装到2个自定义类:Stage和GameApplication,最后介绍了pixijs的资源销毁的最佳实践。

在下几个小节中,我将针对我遇到的主要问题和解决方案,进行简要地介绍。


2.2.2 小电视主体渲染问题


小电视人物+ 底座作为一个整体,占据了游戏界面的主要空间。一旦这个主体资源的位置在渲染时发生偏移,或者大小不适配屏幕,问题会变得非常显眼。因此,小电视主体资源必须精确地定位在游戏界面上,而且其宽高要适应不同屏幕宽度的机型。

针对这些问题,我们向设计提出,小电视主体的图片素材,必须按照375 * 667(或其倍数)的标准尺寸提供给我们,相比原尺寸多出的地方,用透明背景来填充。

在编写代码时,我们创建完小电视的动画”精灵“以后,首先计算了当前屏幕宽度相对于375像素的倍数,再按这个倍数对图片纹理进行缩放,以适应不同屏幕宽度的机型。

在开发的过程中我们遇到了一个小插曲:我们在电脑浏览器上开发的时候,整个游戏能正常地渲染,然而当我们在移动端运行项目的时候,整个游戏界面就黑屏了!此时,我的脑海里产生了2种可能的原因。

第一种可能的原因:pixijs v7版本不兼容移动端,或者某个部分在移动端有问题。我用pixijs v7新写了一个demo,用了一些简单的图片素材,结果它在我的手机上是可以正常渲染的。好吧,pixijs v7版本的确在部分机型上有兼容性问题,但这是另一个问题,并不是这个问题的原因。

第二种可能的原因:Assets在加载图片资源的时候有问题,导致图片纹理全部丢失或损坏了。我使用VConsole等调试工具,在移动端查看了是否发出了图片资源的请求,结果是确实请求了图片资源。因为在电脑浏览器上可以渲染,我认为这大概率也不是这个问题的原因。

正在我一筹莫展,像无头苍蝇一样逛谷歌和各种论坛的时候,一个词出现在了我的视野中:MAX_TEXTURE_SIZE。WebGL对纹理的最大尺寸进行了限制,在电脑的浏览器上一般是4096*4096,而在移动端则一般是2048*2048。这个限制可能考虑到了较大的纹理尺寸对内存、GPU运算速度和显示效果的负面影响。(简单来理解,纹理越大,塞到GL Buffer的难度越大,GPU运算得越慢,显示得越慢越粗糙)我们在一开始渲染小电视主体的时候,使用的雪碧图:每张图片是3倍图,其尺寸也就是1125*2001,一共8张图片组成一张雪碧图。很明显,这张雪碧图的尺寸远远超出了MAX_TEXTURE_SIZE。

之后,我们选择将这张雪碧图重新拆成一张张小的图片,通过纹理数组的方式传入到AnimatedSprite类里,成功解决了渲染黑屏的问题。


2.2.3 其他静态对象的定位问题


在考虑其他静态对象的渲染方案时,就不能直接套用小电视主体的方案了。一方面,如果这些小的静态对象也采用375*667的尺寸的图片资源,这个项目的图片所占用的体积就会非常大,浪费带宽。另一方面,这些静态对象对定位的精度要求较低,即使偏了一点,用户大概率也能接受。因此,我选择保持这些静态对象的图片的原尺寸,手动计算其位置坐标。

在计算坐标之前,我们需要了解一个前提条件:pixijs的Sprite对象的中心点默认在左上角,碰巧的是,页面渲染的原点也在左上角。因此当我们因为屏幕宽度不同而对静态对象进行缩放时,与我们对静态对象进行位移以确定其在页面上的位置时,二种操作的原点是一致的。

基于这个前提条件,我们可以先对静态对象进行缩放操作,以适配非375px的屏幕宽度的机型,然后再对静态对象进行位移操作,位移的坐标是基于375px的屏幕宽度下。注意,先缩放后位移


const btn = new Sprite(assets.btnYellow)const scaleRatio = this.app.screen.width / 375 / 3 // 素材是3倍图btn.scale.set(scaleRatio)btn.x = 28btn.y = 570


2.2.4 pixijs v7的兼容性问题


pixijs v7最重大的改动,就是删除了polyfill,取消了对旧版本浏览器的支持。这个问题直到测试的时候才被发现。在有限的测试机型中,我们遇到的兼容性问题如下:

1.  ios11不支持扩展运算符(...)。可以使用babel插件:@babel/plugin-proposal-object-rest-spread

2.  安卓7不支持globalThis。可以使用babel插件:babel-plugin-transform-globalthis

3.  低版本浏览器不支持array.prototype.flat和array.prototype.flatMap。这个没有找到很优雅的解决方案。我是直接在入口文件里引入了core-js里的2个js文件。


import 'core-js/modules/es.array.flat-map'import 'core-js/modules/es.array.flat'


三、撒金币等其他动效


在游戏主体之外,仍有一些大大小小的动效,需要不一样的技术方案去实现。概括来说,我们主要尝试了PAG、SVGA和视频这3种方案。首先,我将简单介绍这3种方案是什么,然后再通过2个实际案例来说明,我们是如何从这3种方案中选择,并加以实现的。


3.1 技术方案


3.1.1 PAG


PAG(Portable Animated Graphics)是由腾讯自主研发的一套完整的动效工作流解决方案。设计师在AE中制作动效以后,可以通过PAG Exporter导出.pag格式的素材文件,并通过其SDK将素材文件应用于移动端、桌面端、Web端和小程序端等不同平台上。

此外,官网还提供了PAGViewer,你可以通过它来预览pag文件的效果。

PAG官网是https://pag.art/


3.1.2 SVGA


SVGA是由YY团队开发的一种动画格式,可以兼容iOS、Android、Flutter和Web多个平台。虽然名字里包含SVG,但SVGA不仅可以兼容矢量图形,还可以兼容位图。

SVGA的官网是https://svga.io/

我们常用的是官网提供的web端SVGAPlayer库:svgaplayerweb,其github仓库地址为https://github.com/svga/SVGAPlayer-Web,其npm地址为https://www.npmjs.com/package/svgaplayerweb


3.1.3 视频


关于动画,我们常用的视频文件格式是MP4。MP4是一种多媒体文件格式,常用于储存视频和音频数据。


3.2 具体案例


3.2.1 撒金币动效


针对撒金币等这些小动效,我们首先尝试了PAG的方案。

但是PAG方案在实现时出现了2个明显的问题:第一,一个PAG文件就要占用了一个canvas元素。如果以一个页面需要展示多个PAG实现的动画,就需要创建多个canvas元素。这会对内存造成很大的开销,也并非最佳实践。第二,PAG在移动端的性能较差。实际使用时,动画视频出现了末尾掉帧的情况(动画停在了最后一帧)。官方的一篇“兼容性情况”的文章也提到了这一个问题:https://pag.art/docs/web-sdk/compatibility.html


【图片 —— PAG动画在移动端“末尾掉帧”问题】


抛弃了PAG以后,我们转而考虑SVGA。SVGA的兼容性和性能都比较好,但是文件体积会比PAG大很多。好在这些小动效的文件体积仍在可以接受的范围内(1-2M)。详细的文件体积的对比,我列在下方。


动画
PAG文件体积
SVGA文件体积

撒金币动画

212K

1M

“再加2次游戏机会”动画

489K

2.3M

【表格 —— 撒金币动效PAG/SVGA文件体积对比】


3.2.2 KV动效


针对KV动效,我们的技术方案也经历了从PAG到SVGA的选择转变。然而,KV的SVGA文件体积实在太大了。KV分为休息状态和普通状态2种动画,文件体积分别是10M(20帧,1125 *1500)和25M(50帧,图片尺寸1125*1500),会对带宽造成巨大的开销。

没办法,我们只能将目光投向MP4方案,原因有2条:第一,KV动画只需要播放,不需要多余的处理逻辑。第二,MP4文件的体积可以被压缩得较小。详细的文件体积的对比,我列在下方。


动画

PAG文件体积

SVGA文件体积

MP4文件体积

KV休息态

543K

10M

(20帧,1125 * 1500)

714K

KV普通态

1.3M

25M

(50帧,1125 * 1500)

2M

【表格 —— KV动效PAG/SVGA/MP4文件体积对比】


在使用MP4文件时,我们第一时间想到的就是在html模版里直接使用video标签,并将src设置成MP4文件的链接。如果我们需要让视频自动播放,我们通常会让视频静音起播。然而这种写法在ios是不行的,我们必须将这段创建video标签的代码通过v-html指令,动态插入到html模版中。


<template>  <div class="key-vision-video-wp" v-html="getVideoHtml()"></div></template>
<script>export default {  methods: {    getVideoHtml() {     const video_url = this.videoType === 'normal' ? VIDEO_NORMAL : VIDEO_RESET      // html写法可以兼容ios自动播放,oncanplay为了处理视频加载时候有播放按钮的情况      return `<video      id="video"      preload="auto"      muted="muted"      autoplay="autoplay"      playsinline=""      webkit-playsinline=""      loop="loop"      style="opacity:0"      oncanplay="style.opacity=1;window.daliOnVideoCanPlay()"      class="video show-video"      <source src="${video_url}" type="video/mp4">      </video>`        }  }}<script>


这种写法能解决视频在大多数ios机型上无法自动播放的问题。

针对以上的技术方案的优点和缺点,我总结了如下的一张表格。


技术方案
优点
缺点

PAG

文件体积最小

移动端性能和兼容性较差

SVGA

性能和兼容性最好

文件体积较大

MP4

文件体积中等

性能和兼容性中等

ios机型有自动播放问题

低版本安卓机型会显示播放按钮图标

【表格 —— PAG/SVGA/MP4技术方案对比】


四、总结


本篇文章介绍了大力出奇迹活动的小游戏,通过划分3个主要部分,并介绍这些动效的技术方案和实现细节,还原了我们对小游戏技术方案的探索过程,希望通过这个案例的经验和实现细节给读者一些帮助。后续将会有一篇关于动效方案选型建议的文章,读者们敬请期待。


以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!


往期精彩指路

继续滑动看下一个
哔哩哔哩技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存